Skip to content

feat(netbird): enroll mitmproxy as a mesh peer - #107

Open
wiacekm wants to merge 137 commits into
VirtusLab:masterfrom
wiacekm:sandcat-netbird-integration
Open

feat(netbird): enroll mitmproxy as a mesh peer#107
wiacekm wants to merge 137 commits into
VirtusLab:masterfrom
wiacekm:sandcat-netbird-integration

Conversation

@wiacekm

@wiacekm wiacekm commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Enroll mitmproxy as a NetBird mesh peer so a sandcat agent can reach overlay services without putting the NetBird client on wg-client (that path collided with wg0).

sandcat init --netbird (optional --netbird-management-url) turns mitmproxy into the mesh client: pinned NetBird 0.72.4 on wt0, project-scoped peer name {compose_project}-proxy, persisted /var/lib/netbird state, same-name replace when local state is gone, and a dns_label PATCH so the FQDN is stable. Agent traffic stays agent → wg0 → mitmproxy (Layer 1 + secrets) → internet or wt0. wg-client stays a tunnel shim; mesh DNS is published into the agent namespace via dnsmasq.

Sandcat configures connection details only. It does not start a management server and there is no sandcat netbird host CLI — peers and ACL live in the dashboard. Credentials (netbird_enrollment_key, netbird_api_token) may be literals or op:// / pass:// (or {value|op|pass} objects); the host flattens objects into NB_* env and never shells out to op/pass-cli. Resolve happens inside mitmproxy, fail-closed on the replace path. Setup key and PAT stay in user settings; they are stripped from the copy bind-mounted into the agent.

Operator examples (manual, not created by init):

  • docs/examples/netbird-server/ — self-hosted combined 0.72 stack
  • docs/examples/proxy-peer/ — compose+env HTTP gateway (GET /hello on 8080); Layer 1 allow + dashboard ACL (sandcat-proxyproxy-peer TCP 8080); delete the Default AllAll policy; no NetBird Networks/legacy Routes

Test plan

  • bats composefile NetBird + contract tests (single NET_ADMIN on wg-client in the template; mitmproxy gets NET_ADMIN only after enable_netbird; checksum-pinned Dockerfile.mitmproxy)
  • bats init/devcontainer/regression: --netbird wiring, peer-name seed/override, compose include vs compose-all.yml
  • bats mitmproxy peer lifecycle + secret flatten (op:// / pass:// stubs, object flatten, PAT skip-replace)
  • bats mitmproxy + wg-client NetBird DNS
  • bats netbird-server start helper + proxy-peer compose contract
  • Manual: sandcat init --netbird, confirm mitmproxy peer in the dashboard, netbird status --json shows 0.72.4 and rel://
  • Manual: bring up docs/examples/proxy-peer/ with both env-files and --build; Layer 1 allow + ACL; from the agent GET http://<gateway-fqdn>:8080/hello
  • Confirm cli/yq is not in the PR

@adamw

adamw commented Sep 9, 2026

Copy link
Copy Markdown
Member

Automated review. Generated by Claude Code from seven reviewer agents (functionality, security, structure, readability, simplicity, tests, performance) plus a verification pass. Line numbers refer to the PR head (d26bb2d). Findings marked verified were reproduced locally.

Blockers

  • Entrypoint replacement drops master's behavior (cli/lib/composefile.bash:446). enable_netbird deletes the compose entrypoint and replaces it with mitmproxy-init.sh, which only re-does rm -f dns.conf. Master's entrypoint also chowns /mitmproxy-public and publishes the CA cert there, and master's healthcheck gates on that cert. After rebase, mitmproxy never becomes healthy and wg-client never starts. The PR currently conflicts with master in five files. Verified.
  • Healthcheck budget is ~30s but startup can take minutes (compose-proxy.yml:71). netbird_verify_host_management_reachable alone is ~95s worst case (16 × curl --max-time 5 + sleeps), plus daemon wait (30s), wt0 wait (30s), and the addon's new _pass_cli_warmup and per-secret retries (3 × 60s each). Only wg-client got a start_period. The comment in main() says enrollment must not block the proxy, but it runs synchronously before exec.
  • Digit-only or boolean-looking secrets are rejected (cli/lib/netbird.bash:56). yq -o json -r strips quotes, and netbird_flatten_secret_setting re-parses the text as YAML. A token of 0123456789 types as !!float, the flatten fails, and compose/run/attach/restart-proxy all abort. A value of null is silently treated as unset. Verified.
  • Two existing pytest tests fail (test_mitmproxy_addon.py:1132, :1163). They assert timeout=30; the code now uses 60. The unpatched time.sleep in the new retry loop also adds ~20s to the suite. Clean at the merge base. Verified.
  • Stale dns.conf removed too late (mitmproxy-init.sh:377). It is deleted after start_netbird instead of first. On restart with the persisted volume, the healthcheck passes on the previous run's file and wg-client reads a stale upstream.

Security (high)

  • mitmweb is reachable from the whole mesh with the fixed password mitmproxy. enable_netbird puts wt0 in the mitmproxy netns; only OUTPUT rules are added. NetBird's default policy is all-to-all. Any peer can open port 8081 and read intercepted traffic, including resolved secrets. Add an INPUT drop on wt0 for 8081 and the WG port, and generate the password.
  • Repo-controlled peer name triggers an authenticated DELETE (netbird-peer-lifecycle.sh:278-302). netbird_peer_name_proxy is read from the project's committed .sandcat/settings.json. Cloning a hostile repo and starting the stack deletes whatever peer that repo names from the operator's account. Derive the name from the project instead of trusting settings.
  • PAT and setup key are readable from the agent container. netbird_api_token / netbird_enrollment_key are layered over .sandcat/settings*.json, and .sandcat is mounted into the agent. The README recommends this layering. Either drop the project layers for these keys or exclude them from the mount.

Important

  • Peer FQDNs from the management server go into dnsmasq with only a substring match on the domain (mitmproxy-init.sh:281). A name containing / becomes a multi-domain address= line and hijacks a whole TLD inside the sandbox. Anchor the suffix and validate the charset.
  • local=/<domain>/ (mitmproxy-init.sh:303) cancels the server=/<domain>/<ns> line at :272, so wildcard mesh resolution never works once any peer exists.
  • Alias regex ^(.+)-[0-9]{1,3}-[0-9]{1,3}$ (mitmproxy-init.sh:289) strips two of four octets: x-100-64-0-5 yields alias x-100-64. Verified.
  • mv "$tmp" "$profile_file" runs even when jq failed (netbird-peer-lifecycle.sh:179-200), leaving an empty default.json. set -e does not help because the call chain runs inside if start_netbird.
  • Cloud enrollment inherits a stale self-hosted URL from global settings.json (mitmproxy-init.sh:338); the cloud path never clears it.
  • find_repo_root uses $PWD, so sandcat init --path other --netbird reads the current directory's .sandcat/settings.json and bakes that server into the other project (cli/lib/netbird.bash:25).
  • No trap in either script. exec gosu orphans the NetBird daemon and both supervisor loops under mitmweb, so docker stop never runs netbird down. supervise_netbird_daemon also respawns netbird service run without checking the previous one is alive, and re-enrolls every 10s with no backoff, each time fetching the full peer list and possibly deleting the peer.
  • netbird up has no timeout (mitmproxy-init.sh:193, :231). Setup key and PAT are passed on argv to netbird and curl; use --setup-key-file and curl -H @-.
  • netbird-peers.conf is never truncated when there are no peers and the cp is not atomic (mitmproxy-init.sh:308-315).
  • The two-call retry in netbird_resolve_secret_ref (netbird-peer-lifecycle.sh:59-63) writes both attempts to stdout, so a partial first output corrupts the captured secret.
  • restart_dnsmasq starts a second dnsmasq even if the old one never exited, and logs success regardless (wg-client-init.sh:158-175). Every peer change restarts dnsmasq, giving the agent up to ~10s of DNS outage.
  • _volume_timestamp_epoch (cli/lib/volume.bash:13) only strips a trailing Z; a numeric offset from a non-UTC daemon fails on GNU date and is silently wrong on BSD date. Regression from master's date -d.
  • "${COMPOSE_ARGS[@]}" under set -u fails on macOS bash 3.2 when empty (start.sh:149). The CLI already uses the ${a[@]+"${a[@]}"} guard elsewhere.
  • start.sh writes generated server secrets into the tracked netbird-server.env with default umask.
  • wg-client-init.sh re-does the full merge every 5s even when the mtime it computes is unchanged; a one-line early return fixes it.

Cleanup

  • docs/examples/proxy-peer/scripts/proxy-peer-init.sh duplicates ~150 lines of mitmproxy-init.sh and has already drifted. Both source netbird-peer-lifecycle.sh, so the shared home exists. The four tests that grep both copies for the same string go away with it.
  • ~200 lines of NetBird compose logic live in composefile.bash, which sources netbird.bash mid-function. init defines three functions inside init(). apply_secret_provider now branches on the mitmproxy dockerfile.
  • The eight idempotency guards in enable_netbird are dead: the template is copied fresh immediately before.
  • ~26 tests grep source text or assert line order (netbird_contract.bats, netbird_peer_lifecycle.bats:245-290, proxy_peer_compose.bats, wg-client/netbird_dns.bats:138-153). No test covers a failing GET or DELETE in the deregistration path. Several tests are duplicates (three idempotence tests, two --netbird-management-url tests, two --capability tests).
  • Four unrelated changes ride along: the yq SIGSEGV retry in require.bash, BSD date in volume.bash, the pass-cli warmup, and the enabled flag parser (~35 lines for a boolean).
  • Every attach/compose/run/restart-proxy now spawns ~10 yq processes even with NetBird disabled.
  • docs/examples/netbird-server (426 lines) is not used by the CLI. docs/ is now a Sphinx source tree on master; runnable examples belong outside it.
  • netbird_mgmt_delete_peer_by_name and apply_netbird_build_args's wg-client default are only used by their own tests. netbird.env says wg-client consumes it; the consumer is Dockerfile.mitmproxy.
  • New bats files use spaces; .editorconfig mandates tabs.

Test run

In a worktree at the PR head: bats 389 passed, 5 failed (2 pre-existing on master, 3 in netbird_server_start.bats from SHELLOPTS leaking nounset into the pyenv shim). pytest 329 passed, 2 failed (above).

Michał Wiącek and others added 29 commits September 10, 2026 06:11
Co-authored-by: Cursor <cursoragent@cursor.com>
… netbird container

Co-authored-by: Cursor <cursoragent@cursor.com>
Add netbird.env as the single source of truth for version and tarball
checksums, verify downloads in Dockerfile.wg-client before extract, inject
build args at init via apply_netbird_build_args, and fix stale peer-remove
comment in netbird.bash.
Read netbird_api_token and netbird_enrollment_key from user, project, and
local settings layers (env overrides). Export NB_SETUP_KEY before docker
compose in sandcat compose/run/attach/restart-proxy. Seed netbird_api_token
on init --netbird and document settings-based configuration.
Enable cloud/existing/new NetBird management server flows in init and persist the selected management URL so runtime commands and compose wiring resolve it automatically. Add a self-hosted server template scaffold and focused tests/docs so self-hosted onboarding is explicit and repeatable.
Add `sandcat netbird server start|stop|status` as a thin wrapper around
the provisioned self-hosted stack in ~/.config/sandcat/netbird-server.
Improve API error handling for NetBird’s misleading 404-on-invalid-token
responses, keep settings-sourced PATs in a local variable instead of
exporting NB_API_TOKEN, and guard jq pretty-printing when the response
is not valid JSON.
Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce CapabilityRuntimeError hierarchy with domain-specific exceptions for visibility, lease, and bundle version failures.
Introduce LeaseDecision and LeaseManager to issue time-bounded leases with
quota decrement, exhaustion checks, and expiry evaluation for PoC 1 flows.
RevocationManager revokes by lease or ref with fail-closed catalog state.
discover_capabilities returns metadata for Discoverable caps only.
Add CapabilityRuntime class that wires together catalog, leases, revocation,
and observability. Implements all 7 protocol surfaces from spec §3.2:
- check_current_capabilities: returns bundle with visible/leased tools
- request_capability_lease: grants leases with PoC 1 params
- revoke_capability: revokes by lease ID or capability ref
- discover_capabilities: discovers by intent query
- emit events: delegates to observability collector
- record_action: decrements quota and revokes when exhausted
- enforce_action: validates bundle version and visibility

Includes integration test for full PoC 1 lifecycle (create_pr invisible
→ lease → visible → use → gone) plus unit tests for revoke, discovery,
and version mismatch scenarios.

All 23 tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
Thin check-then-act wrapper that re-fetches bundle version before each action and records leased tool usage.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wrap MCP-delivered tools via McpToolAdapter with write_note lifecycle
(quota=3, ttl=5m) and capability-specific lease params in the runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
Demonstrates the §5.1 lease lifecycle with AgentExecutionLoop and agent
adaptation to draft_pr when create_pr is exhausted.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ackend protocol

Co-authored-by: Cursor <cursoragent@cursor.com>
…mentations

Co-authored-by: Cursor <cursoragent@cursor.com>
…etwork revoke

Implements PhysicalRevocationBackend by delegating route and peer removal to an injectable NetBirdClient.

Co-authored-by: Cursor <cursoragent@cursor.com>
Michał Wiącek and others added 21 commits September 10, 2026 06:25
Sandcat now only stores management URLs and keys. Drop --netbird-server,
sandcat netbird server, and --proxy-peer; operators run those stacks from
docs/examples/. Remove demo scripts and engineering-gate wrappers.
Missing-mount copy must not lead with re-init (that overwrites the project catalog). Default-no-restart must be asserted on a mounted project with a zero-call docker stub so compose cannot sneak through. Document that restart does not remount a new inode.
Capability-runtime is leaving this PoC; unknown-option is the operator-facing cut.

Co-authored-by: Cursor <cursoragent@cursor.com>
The PoC no longer ships leases, catalog edit, or the Python control plane.

Co-authored-by: Cursor <cursoragent@cursor.com>
Layer 1 remain the only in-proxy deny; mesh policy is the NetBird dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
Peer and ACL administration is the NetBird dashboard; compose still exports enroll env.

Co-authored-by: Cursor <cursoragent@cursor.com>
Host exports literals or op:// pass:// paths; it never calls a secret provider CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace and dns_label keep using a plaintext PAT after in-container lookup.

Co-authored-by: Cursor <cursoragent@cursor.com>
Raw jq into NB_SETUP_KEY skipped object-shaped secrets before in-container resolve.

Co-authored-by: Cursor <cursoragent@cursor.com>
The gateway is a compose PoC; tokens stay in a gitignored .env.

Co-authored-by: Cursor <cursoragent@cursor.com>
Operator docs no longer describe capability leases or sandcat netbird.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mesh permission is dashboard ACL, not a NetBird route on wt0.
Hung op/pass-cli on mitmproxy startup could block gosu; raw jq dumped
object-shaped tokens as garbage PATs. Timeouts, Proton warmup/retry, and
flatten+resolve keep replace fail-closed without blocking mitmweb.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ded relay

Match the combined server Relays list, coerce 0.72 ManagementURL objects, keep
enrollment after replace failures, advertise STUN via docker0, and replace stale
wg-client mesh DNS when peer IPs change.

Co-authored-by: Cursor <cursoragent@cursor.com>
Setup-key-only first enroll was aborting because same-name lookup needs an
API token. Skip replace when no token is present; still fail if a token is
set and delete fails. Reject non-PAT pass-cli sessions before pass://
resolve. Drop the example lifecycle copy and build from the CLI template.
Empty .env.example, cut the second CLI server walkthrough, and remove the
accidental cli/.version.
@wiacekm
wiacekm force-pushed the sandcat-netbird-integration branch from d26bb2d to d194bbf Compare September 10, 2026 21:13
Michał Wiącek and others added 3 commits September 11, 2026 06:35
Route agent mounts and env into compose-agent.yml so Compose include no
longer conflicts with compose-all.yml, keep NetBird BASE_IMAGE pins on
SCT_MITMPROXY_VERSION, and fix bash-3 cache mapfile, installer PATH
stubs, and the yq function PATH trap in secret-flatten tests.
Enrollment no longer blocks mitmweb; health sentinels and the CA publish
run first. Peer names come from the project, enroll secrets stay in user
settings, and mitmweb is no longer reachable on wt0 with a fixed password.

Co-authored-by: Cursor <cursoragent@cursor.com>
Cache the mikefarah require check, skip stdout capture on in-place
edits, and batch volume and capability writes into one yq -i each.

Co-authored-by: Cursor <cursoragent@cursor.com>
@wiacekm
wiacekm force-pushed the sandcat-netbird-integration branch from d194bbf to 1f045ac Compare September 11, 2026 07:26
Route agent mounts and env into compose-agent.yml so Compose include no
longer conflicts with compose-all.yml, keep NetBird BASE_IMAGE pins on
SCT_MITMPROXY_VERSION, and fix bash-3 cache mapfile, installer PATH
stubs, and the yq function PATH trap in secret-flatten tests.
@wiacekm
wiacekm force-pushed the sandcat-netbird-integration branch from 1f045ac to a667f1d Compare September 11, 2026 11:28
@wiacekm
wiacekm marked this pull request as ready for review September 11, 2026 12:06
@wiacekm wiacekm changed the title Sandcat netbird integration feat(netbird): enroll mitmproxy as a mesh peer Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants